# -*- coding: utf-8 -*-

import base64,requests,os,json,re,math,time,winsound
from pyDes import des, PAD_PKCS5, CBC
import threading
from tqdm import tqdm

def saveConfigs():
  cfg = json.dumps({
    'dual_thread': dual_thread,
    'download_home': download_home,
    'search_key': search_key,
    'show_type': show_type,
    'config_page': config_page,
    'config_num': config_num,
  }, ensure_ascii=False).encode()
  with open(config_name,'wb') as cf:
    cf.write(cfg)
    cf.flush()

download_home=os.path.abspath('./music').replace('\\','/')+'/'  #默认地址
dual_thread = 1 # 多线程下载 线程数量
search_key = "周杰伦" # 默认搜索Key
c_show_type=('不限制','仅歌手','仅专辑','仅歌手仅原版')
show_type=0 # 筛选 0不限制 1歌手 2专辑
config_page=1 # 上次页码
config_num=100  # 单页数量
config_name = "config.json" # 配置项名称
if not os.path.exists(config_name): # 配置文件不存在
  saveConfigs()

with open(config_name,encoding='utf-8') as cfg: #读配置文件
  list = cfg.read()
  params = json.loads(list)
  download_home = params['download_home']
  show_type = params['show_type']
  search_key = params['search_key']
  dual_thread = int(params['dual_thread'])
  config_page = int(params['config_page'])
  config_num = int(params['config_num'])

# 加解密工具
def decryptAndSetCookie(text: str):
  replace = text.replace("-", "").replace("|", "")

  if len(replace) < 10 or replace.find("%") == -1:
    return False

  split = replace.split("%")
  key = split[0]
  qq = str(decryptDES(split[1], key[0:8]), "utf-8")
  if len(qq) < 8:
    qq += "QMD"
  mkey = str(decryptDES(key, qq[0:8]), "utf-8")
  return mkey, qq   # 用对象的encrypt方法加密

# des解密
def decryptDES(strs: str, key: str): return des(
  key, CBC, key, padmode=PAD_PKCS5).decrypt(base64.b64decode(str(strs)))


# des加密
def encryptDES(text: str, key: str): return str(base64.b64encode(
  des(key, CBC, key, padmode=PAD_PKCS5).encrypt(text)), 'utf-8')


# 加密字符串
def encryptText(text: str, qq: str):
  key = ("QMD"+qq)[0:8]
  return encryptDES(text, key)


# 解密字符串
def decryptText(text: str, qq: str): return str(decryptDES(
  text.replace("-", ""), ("QMD" + qq)[0:8]), 'utf-8')


def getHead():
  return {
    'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36',
    'content-type': 'application/json; charset=UTF-8',
    "referer": "https://y.qq.com/portal/profile.html"
  }


sess = requests.Session()


def clear():
  os.system('cls')
  #print('\033c', end='')


def buildSearchContent(song='', page=1, page_per_num=100):
  return {
    "comm": {"ct": "19", "cv": "1845"},
    "music.search.SearchCgiService": {
      "method": "DoSearchForQQMusicDesktop",
      "module": "music.search.SearchCgiService",
      "param": {"query": song, "num_per_page": page_per_num, "page_num": page}
    }
  }


def searchMusic(key="", page=1):
  # base url
  url = "https://u.y.qq.com/cgi-bin/musicu.fcg"
  # base data content from qqmusic pc-client-apps
  data = buildSearchContent(key, page,config_num)
  data = json.dumps(data, ensure_ascii=False)
  data = data.encode('utf-8')
  res = sess.post(url, data, headers=getHead())
  jsons = res.json()

  # 开始解析QQ音乐的搜索结果
  res = jsons['music.search.SearchCgiService']['data']
  list = res['body']['song']['list']
  meta = res['meta']

  # 数据清洗,去掉搜索结果中多余的数据
  list_clear = []
  for i in list:
    list_clear.append({
      'album': i['album'],
      'docid': i['docid'],
      'id': i['id'],
      'mid': i['mid'],
      'name': i['title'],
      'singer': i['singer'],
      'time_public': i['time_public'],
      'title': i['title'],
      'file': i['file'],
    })

  # rebuild json
  # list_clear: 搜索出来的歌曲列表
  # {
  #   size 搜索结果总数
  #   next 下一搜索页码 -1表示搜索结果已经到底
  #   cur  当前搜索结果页码
  # }
  return list_clear, {
    'size': meta['sum'],
    'next': meta['nextpage'],
    'cur': meta['curpage']
  }


def getCookie():
  uid = "822a3b85-a5c9-438e-a277-a8da412e8265"
  systemVersion = "1.7.2"
  versionCode = "76"
  deviceBrand = "360"
  deviceModel = "QK1707-A01"
  appVersion = "7.1.2"
  encIP = encryptText(
    f'{uid}{deviceModel}{deviceBrand}{systemVersion}{appVersion}{versionCode}', "F*ckYou!")

  u = 'http://8.136.185.193/api/Cookies'
  d = f'\{{"appVersion":"{appVersion}","deviceBrand":"{deviceBrand}","deviceModel":"{deviceModel}","ip":"{encIP}","systemVersion":"{systemVersion}","uid":"{uid}","versionCode":"{versionCode}"\}}'.replace(
    "\\", "")

  ret = sess.post(u, d, headers={
    'Content-Type': 'application/json;  charset=UTF-8'
  })
  return ret.text


def getDownloadLink(fileName):
  u = 'http://8.136.185.193/api/MusicLink/link'
  d = f'"{encryptText(fileName, mqq_)}"'
  ret = sess.post(
    u, d, headers={
      "Content-Type": "application/json;charset=utf-8"
    })
  return ret.text


def getMusicFileName(code, mid, format): return f'{code}{mid}.{format}'


def getQQServersCallback(url, method=0, data={}):
  global mqq_
  global mkey_
  d = json.dumps(data, ensure_ascii=False)
  h = {
    'referer': 'https://y.qq.com/portal/profile.html',
    'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36',
    'cookie': f'qqmusic_key={mkey_};qqmusic_uin={mqq_};',
    'content-type': 'application/json; charset=utf-8'
  }
  if method == 0:
    d = sess.get(url, headers=h)
  else:
    d = sess.post(url, d, headers=h)
  return d


def getMediaLyric(mid):
  d = getQQServersCallback(
    f'https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg?songmid={mid}&g_tk=5381')
  d = d.text  # MusicJsonCallback(...)
  d = d[18:-1]
  return json.loads(d)


def parseSectionByNotFound(filename, songmid):
  d = getQQServersCallback('https://u.y.qq.com/cgi-bin/musicu.fcg', 1, {"comm": {"ct": "19", "cv": "1777"}, "queryvkey": {"method": "CgiGetVkey", "module": "vkey.GetVkeyServer",                 "param": {
    "uin": mqq_,
    "guid": "QMD50",
    "referer": "y.qq.com",
    "songtype": [1],
    "filename": [filename], "songmid": [songmid]
  }}})
  d = d.json()
  vkey = d['queryvkey']['data']['midurlinfo'][0]['purl']
  return vkey


mkey_ = ""
mqq_ = ""


def downSingle(it,index,total):
  global download_home
  songmid = it['songmid']
  file = getMusicFileName(
    it['prefix'], it['mid'], it['extra'])
  log = f"{it['singer']} - {it['title']} [{it['notice']}] {round(int(it['size'])/1024/1024,2)}MB - {file}"
  link = getDownloadLink(file)
  #print('一次解析链接',link)
  if link.find('qqmusic.qq.com') == -1:
    if link.find('"title":"Not Found"') != -1:
      # 开始第二次解析
      vkey = parseSectionByNotFound(file, songmid)
      if vkey == '':
        print(f"找不到资源文件! 解析歌曲下载地址失败！{log}")
        return False
      link = f'http://ws.stream.qqmusic.qq.com/{vkey}&fromtag=140'
      #print('二次解析链接',link)
    else:
      print(f"无法加载资源文件! 解析歌曲下载地址失败！{log}")
      return False

  # prepare
  path_music = f"{it['singer']} - {it['title']}.{it['extra']}"
  path_lrc = f"{it['singer']} - {it['title']}.lrc"
  
  replace_str=r'[\\/:*?"<>|]'
  path_music=re.sub(replace_str,'_',path_music)
  path_lrc=re.sub(replace_str,'_',path_lrc)

  path_singer=re.sub(replace_str,'_',it['singer'])
  path_album=re.sub(replace_str,'_',it['album'])
  if '.'==path_singer[-1]:
    path_singer+='_'
  if '.'==path_album[-1]:
    path_album+='_'

  my_path = download_home+path_singer+'/'+path_album
  if not os.path.exists(my_path):
    os.makedirs(f"{my_path}")

  localFile=os.path.join(my_path, f"{path_music}").replace('\\','/')
  localLrcFile=os.path.join(my_path, f"{path_lrc}").replace('\\','/')

  a=''
  if -1!=index and -1!=total:
    a=f'{index}/{total} | '
  print(f'{a}下载 | {path_album} / {log}')
  
  # 下载歌词
  if not os.path.exists(localLrcFile):
    print(f"\t歌词: {localLrcFile}.")
    lyric = getMediaLyric(songmid)  # lyric trans
    if int(lyric['retcode']) == 0:
      # "retcode": 0,
      # "code": 0,
      # "subcode": 0,
      # {'retcode': -1901, 'code': -1901, 'subcode': -1901}
      # 外语歌曲有翻译 但是👴不需要！
      lyric = base64.b64decode(lyric['lyric'])
      with open(localLrcFile, 'wb') as code:
        code.write(lyric)
        code.flush()
    else:
      print(f"\t歌词获取失败!服务器上搜索不到此首 [{it['singer']} - {it['title']}] 歌曲歌词!")
  else:
    print(f"\t歌词已下载: {localLrcFile}.")

  # 下载歌曲
  if os.path.exists(localFile):
    if os.path.getsize(localFile) == int(it['size']):
      print(f"\t歌曲已下载: {localFile}")
      return True
    else:
      print(
        f"\t本地文件尺寸不符: {round(os.path.getsize(localFile)/1024/1024,2)}MB/{round(int(it['size'])/1024/1024,2)}MB 覆盖下载 [{localFile}]")

  for i in range(10): #重试10次
    try:
      f=sess.get(link,stream=True)
      total=int(f.headers.get('content-length',0))
      with open(localFile, 'wb') as file, tqdm(
        desc='\t歌曲: '+path_music,
        total=total,
        unit='B',
        unit_scale=True,
        unit_divisor=1024,
        ascii=' #',
        ncols=100,
      ) as bar:
        for data in f.iter_content(chunk_size=1024):
          size=file.write(data)
          bar.update(size)
      break
    except Exception as e:
      i+=1
      print(f'{i}错误:',e)
  return True

'''
  f = sess.get(link)
  with open(localFile, 'wb') as code:
    code.write(f.content)
    code.flush()
'''

def parseList(list, target):
  add = 1
  span = "  "
  songs = []
  lists = []
  for i in list:

    #筛选
    singer = i['singer'][0]['name']
    if 1==show_type and target not in singer:
      continue
    albumName = str(i['album']['title']).strip()
    if not albumName:
      albumName="未分类专辑"
    if 2==show_type and target not in albumName:
      continue
    if 3==show_type:
      if target not in singer:
        continue
      if '(live' in i['title'] or '(Live' in i['title'] or '(LIVE' in i['title']:
        continue
      if 'live)' in i['title'] or 'Live)' in i['title'] or 'LIVE)' in i['title']:
        continue
      if '伴奏' in i['title']:
        continue
    if add > 9:
      span = " "
    if add > 99:
      span = ""

    id = i["file"]
    # 批量下载不需要选择音质 直接开始解析为最高音质 枚举
    code = ""
    format = ""
    qStr = ""
    fsize = 0
    mid = id['media_mid']
    if int(id['size_hires']) != 0:
      # 高解析无损音质
      code = "RS01"
      format = "flac"
      qStr = "高解析无损 Hi-Res"
      fsize = int(id['size_hires'])
    elif int(id['size_flac']) != 0:
      isEnc = False  # 这句代码是逆向出来的 暂时无效
      if(isEnc):
        code = "F0M0"
        format = "mflac"
      else:
        code = "F000"
        format = "flac"
      qStr = "无损品质 FLAC"
      fsize = int(id['size_flac'])
    elif int(id['size_320mp3']) != 0:
      code = "M800"
      format = "mp3"
      qStr = "超高品质 320kbps"
      fsize = int(id['size_320mp3'])
    elif int(id['size_192ogg']) != 0:
      isEnc = False  # 这句代码是逆向出来的 暂时无效
      if(isEnc):
        code = "O6M0"
        format = "mgg"
      else:
        code = "O600"
        format = "ogg"
      qStr = "高品质 OGG"
      fsize = int(id['size_192ogg'])
    elif int(id['size_128mp3']) != 0:
      isEnc = False  # 这句代码是逆向出来的 暂时无效
      if(isEnc):
        code = "O4M0"
        format = "mgg"
      else:
        code = "M500"
        format = "mp3"
      qStr = "标准品质 128kbps"
      fsize = int(id['size_128mp3'])
    elif int(id['size_96aac']) != 0:
      code = "C400"
      format = "m4a"
      qStr = "低品质 96kbps"
      fsize = int(id['size_96aac'])

    songs.append({
      'prefix': code,
      'extra': format,
      'notice': qStr,
      'mid': mid,
      'songmid': i['mid'],
      'size': fsize,
      'title': f'{i["title"]}',
      'singer': f'{singer}',
      'album': albumName})

    time_publish = i["time_public"]
    if time_publish == '':
      time_publish = "0000-00-00"
    lists.append(
      f'{add} {span}{time_publish} {singer} - {i["title"]}{""if 2!=show_type else " / "+albumName}')
    add += 1
  # 这部分其实可以只返回songs 但是代码我懒得改了 反正又不是不能用=v=
  return lists, songs

def download_some(songs,indexs=[]):
  if not indexs:
    indexs=[i for i in range(len(songs))]
  thList = []
  for i in indexs:
    th = threading.Thread(target=downSingle,args=(songs[i],indexs.index(i)+1,len(indexs)))
    thList.append(th)
    th.start()
    if len(thList) == dual_thread:
      while len(thList) > 0:
        thList.pop().join()
  while len(thList) > 0:
    thList.pop().join()

def _main(target="周杰伦"):
  global mkey_
  global mqq_
  global download_home
  global dual_thread
  global show_type
  global search_key
  global config_page
  global config_num

  # fix create directory files error(if not exists)
  if not os.path.exists(download_home):
    os.makedirs(f"{download_home}")

  cookie = getCookie()

  mkey, qq = decryptAndSetCookie(cookie)
  mkey_ = mkey
  mqq_ = qq

  # 根据文件名获取下载链接
  # getDownloadLink("RS01003w2xz20QlUZt.flac")

  # filename = "ID9TZr-ensC/-rJ2t6-atFsm+sRG+2S6CqS"
  # filename = decryptText(filename, qq)
  # # 解密后 RS01 003w2xz20QlUZt . flac
  if config_page<0:
    config_page=0
  while True:
    (list, meta) = searchMusic(target, config_page)
    list, songs = parseList(list, target)
    clear()
    print("============ QQ音乐 最高音质 批量下载 ============")
    for li in list:
      print(li)
    willDownAll = False
    while True:
      print(f"""
[{meta['size']}] {config_page}/{math.ceil(int(meta['size'])/config_num)}
n 切换下一页 (Next)
p 切换上一页 (Previous)
a 一键下载本页所有歌曲 (All)
1 若要下载某些歌曲,请输入歌曲前方的序号,逗号[,]隔开(如: 1,2,3) '-'号前缀反选
s 修改搜索关键词 [{search_key}]
t 当前[{dual_thread}]线程,修改并发. (Thread)
o 切换模式: [{','.join(c_show_type)}] 当前: {c_show_type[show_type]}
h 切换当前下载缓存的主目录.[{download_home}] (Download Home)
m 设置每页歌曲数量 [1,100] 当前: {config_num}

请输入:""", end='')
      inputKey = input()
      if inputKey == "n":
        break
      if inputKey in ('o','m'):
        if 'o'==inputKey:
          print(f"选择模式[{','.join([str(i)+':'+c_show_type[i] for i in range(len(c_show_type))])}]: ", end='')
        elif 'm'==inputKey:
          print('设置每页歌曲数量: ',end='')
        try:
          t=int(input())
        except:
          print("输入无效字符,请重新输入。")
          continue
        if 'o'==inputKey:
          show_type=t
          if show_type not in range(len(c_show_type)):
            show_type=0
        elif 'm'==inputKey:
          if t>0 and t<101:
            config_num=t
        saveConfigs()
        _main(search_key)
        return
      elif inputKey in ('s','h'):
        print(f"请输入新的{'搜索关键词' if inputKey == 's' else '下载主目录'}:",end='')
        if inputKey == 'h':
          download_home = input()
        else:
          search_key = input()
          config_page=1
        saveConfigs()
        _main(search_key)
        return
      elif inputKey == 'a':
        # 下载本页所有歌曲
        willDownAll = True
      elif inputKey == 't':
        print("请输入线程数:", end='')
        dual_thread = int(input())
        saveConfigs()
        continue
      elif inputKey == 'p':
        config_page -= 2
        if config_page + 1 < 1:
          config_page = 0
        break
      if willDownAll:
        download_some(songs)
        willDownAll = False
      else:
        try:
          all=[i for i in range(config_num)]
          if '-'==inputKey[0]:
            indexs=[i for i in all if i+1 not in [int(i) for i in inputKey[1:].split(',')]]
          else:
            indexs=[i for i in all if i+1 in [int(i) for i in inputKey.split(',')]]
        except:
          print("输入无效字符,请重新输入。")
          continue
        download_some(songs,indexs)
      print("下载完成!")
      winsound.Beep(600,800)
      time.sleep(1)
      winsound.Beep(600,800)
      time.sleep(1)
      winsound.Beep(600,800)
    config_page += 1
    saveConfigs()

if '__main__'==__name__:
  _main(search_key)